Single-pass expression analysis groundwork - answer type questions from ExpressionResults#5857
Open
ondrejmirtes wants to merge 335 commits into
Open
Single-pass expression analysis groundwork - answer type questions from ExpressionResults#5857ondrejmirtes wants to merge 335 commits into
ondrejmirtes wants to merge 335 commits into
Conversation
ebb19a0 to
457689b
Compare
staabm
reviewed
Jun 12, 2026
| return $this->withFlavor(false); | ||
| } | ||
|
|
||
| private function withFlavor(bool $fiber): self |
Contributor
There was a problem hiding this comment.
should this read withFiber?
eb31077 to
59cbf22
Compare
59cbf22 to
125cf22
Compare
d158f1b to
f98892f
Compare
…opertyFetch handlers
…tead of Scope::getType in property/nullsafe/boolean/assignop handlers
…uality instead of Scope::getType
… when called inside-out
… the on-demand helpers in AssignHandler
…d operand types directly in BinaryOp/Match
The disjunction-truthy / conjunction-falsey union recovery (an expression both branch scopes narrow but the exact merge left unconstrained) read the ask scope for its does-it-actually-narrow gates. SpecifiedTypes now carries it as a DeferredSpecifiedTypesAugment - the branch types stay pinned from the operand-walk filtered scopes at compose time - and MutatingScope::applySpecifiedTypes() evaluates the gates against the applying scope and unions the produced entries into the applied batch.
The disjunction-truthy projection of conditional-holder narrowings discovered its candidates in the ask scope's registered holders and gated them against ask-scope state. It is now a DeferredSpecifiedTypesAugment carrying the operand-walk truthy scopes; candidate discovery and the gates run against the applying scope in MutatingScope::applySpecifiedTypes(). With this and the two previous steps, the boolean narrowing helpers' ask-scope parameter feeds only the child-result recursion and the disjunction's decided-operand guards - all holder building and branch-union recovery is symbolic until application.
The decided checks in specifyDisjunction (left decidedly false - use the right narrowing only; left decidedly true or right decidedly false - use the left) read the operands' types on the asking scope. They now read the operands' walk-position types - the results' own evaluation points, matching the || handler's own typeCallback - and take only the asked flavour bit. Affects the verdict callbacks of BooleanOrHandler, TernaryHandler's decomposition and EmptyHandler's isset/truthiness disjuncts.
…tion point Same move as the disjunction decided guards: the comparison's own verdict in BinaryOpHandler (previously read from ask-scope storage or tracked state with a callback fallback), the coalesce's right-side-decidedly-false checks in both its specify and create callbacks, and createSubjectTypes' nullsafe short-circuit null-ruled-out check now read the walk results' own memoized types; only the asked flavour bit follows the asking scope.
The issetability gates of empty() narrowing and the coalesce falsey narrowing read the asking scope; they now run on the handler's captured beforeScope (flavour-mapped by the asked bit), like the corresponding typeCallbacks already did. CoalesceCompositionHelper's getFalseySpecifiedTypes takes an explicit evaluation scope; the asking scope stays only as the conduit for the left side's narrowing fan-out.
… ask The isset/empty/ternary decompositions rebuilt their branch scopes from the asking scope on every specify ask. The ternary now reuses the operand walks' own memoized truthy/falsey scopes (the cond/if/else results); empty() and multi-subject isset() derive their fold scopes from the handler's beforeScope once, lazily, and reuse them across asks - isset() additionally memoizes the whole accumulated conjunction closure, which is ask-independent after this change.
… synthetic `$x?->prop` / `$x?->m()` narrowing walked a synthetic BooleanAnd(NotIdentical($x, null), plainTwin) on the asking scope per ask. Both handlers now compose it through specifyConjunction: the receiver-not-null side is the captured receiver result narrowed by NullType in the negated context (the equality null slice), the plain twin side is the already-captured fetch/call result, the left-truthy scope is the ensured-non-null scope the twin was walked on, and the receiver-is-null branch scope derives lazily from beforeScope. The fabricated NotIdentical is only printed into holder keys, never walked.
IssetHandler's top-level chain reader and single/multi-subject chain narrowing builders received the asking scope for their state reads (chain-link re-evaluation, hasVariableType gates, issetability); they now run on the handler's flavour-mapped beforeScope. The decomposition fold conduits keep their given scopes, which are evaluation-point derived since the fold bake.
ExpressionResult::getSpecifiedTypes(TypeSpecifierContext, bool) computes the narrowing once on the flavour-mapped beforeScope and memoizes it; getSpecifiedTypesForScope() reduces every asking scope to its flavour bit and delegates. This is what the symbolic SpecifiedTypes work was for: alternative-form terms, conditional-holder recipes and deferred augments carry the state-dependent math to applySpecifiedTypes(), so a single memoized SpecifiedTypes serves every asking position - rule bridges, boolean compositions and branch derivations alike.
The scope parameter carried no information beyond its flavour bit since the per-context memoization - callbacks were only ever invoked with the flavour-mapped beforeScope. The signature now says so: callable(TypeSpecifierContext, bool $nativeTypesPromoted). Handlers whose narrowing needs an evaluation scope construct it from their captured beforeScope; child-result conduits go through getSpecifiedTypes() directly. The boolean narrowing helpers keep their MutatingScope evaluation-scope parameter, now always supplied by the handler. This closes the resolve-type-rewrite goal: an expression's narrowing is a pure function of (context, flavour), computed once at the expression's own position in the program.
Upstream's fix (b1c0eb2): the right-side-decidedly-false narrowing of ?? must not narrow the left to non-null when the coalesce context still permits a falsey value. Re-applied to the composed specify callback the rebase replayed over it.
f98892f to
4455baa
Compare
The boolean narrowing helpers took the operand branch scopes as eager arguments, so every specify ask on a deep boolean chain derived the opposite-polarity scope of every level - the walk only computes falsey scopes for || (and truthy for &&), so each missing one cost an applySpecifiedTypes with a growing union, quadratic over the chain. They are thunks now, resolved only when a consumer genuinely needs the state: the branch-union recovery when it has candidate expressions, the holder projection when a candidate passes the applying-scope gates, holder recipes when a non-variable target needs pinning. Net analysis time of tests/bench/data/or-chain-resolve-type-blowup.php drops from ~5s to ~1.2s, level with the 2.2.x baseline; the remaining super-linear member flows (the growing object~(C1|...|Ck) subtraction re-normalized per level) are shared with the old-world representation.
The create-side analog of the specifyTypesCallback flip: a type constraint's fan-out is computed at the expression's own evaluation point, with the asking scope reduced to its flavour bit. ExpressionResult::getCreatedTypes() is the new entry point; getCreatedTypesForScope() delegates. Handlers construct their evaluation scope from the captured beforeScope, the same recipe as the specify flip.
Two reads made every match arm see the full original subject type instead of the subject minus the previous arms' values: - MatchHandler computed its arm verdicts from the subject result's walk-position type; they now read the threaded per-arm scope state (getStateType), which carries the previous arms' subtractions. - A rule asking about a synthetic comparison (MatchExpressionRule's Identical($subject, $armValue)) walks it on demand over a duplicate of the live storage, so the subject hit its stored result and answered its walk-position type - the per-scope re-pricing channel the typeCallback flip removed. BinaryOpHandler's operand reader now re-prices operands via getTypeOnScope() with variables opted in (the new repriceVariables parameter), reading them from the walk's own beforeScope - the asking scope for an on-demand synthetic. Fixes ten MatchExpressionRuleTest known-reds; the walk-position semantics of stored-result reads and chain folds are unchanged.
Replaces the repriceVariables flag with the model it was approximating: when a result is asked for its type on a scope, the scope - not the result - owns the answer for every expression it is authoritative about: variables it knows (including $this and parameters, via the variable read), and any expression it tracks a holder for. Only expressions the scope knows nothing about answer from the memoized walk-position type. This is getStateType's contract applied at the ExpressionResult boundary; the old gate excluded variables only because the raw holder read behind it could not answer $this or parameters. The one consumer that genuinely wanted a position-fixed value - the foreach-by-ref write-through, which rebuilds the iteratee with the value variable's latest type - was relying on the stale stored-result read to mean "the iteratee at foreach entry". It now captures that entry type explicitly in the intertwined expression; a live read would union transient mid-iteration value states into the array, which is the loop convergence's job. Eighteen more known-red tests pass, including the rest of the match-arm cluster: rules asking about synthetic comparisons on arm-narrowed scopes now see the narrowed subject through any expression shape, not just variables.
An isset/empty/?? ensure writes non-null device types into scope state so nested fetches walk without spurious possibly-null noise - which made a nullsafe operator inside the ensured subject read its receiver as non-null and drop the short-circuit null from its own type (isset($this->get()?->aaa) saw int instead of int|null, so the isset rules reported "not nullable" instead of "unnecessary ?->"). NonNullabilityHelper now keeps a stack of the ensures in effect during the walk, recording the pre-device types its results already carried; the nullsafe handlers consult it for the receiver's real type. The device types keep doing their job for everything else.
PHPStan runs with gc_disable(), so anything reachable stays allocated. The Ternary/Match handlers capture operand results in WeakMaps keyed by AST nodes - and the parser cache retains ASTs across files, so those entries never died, holding every analysed file's result graph (callbacks, scopes, types) until the process ended. NodeScopeResolver now resets such per-file state at the start of each file's analysis; measured on self-analysis, live results at file boundaries drop to zero and make phpstan's used memory goes from 2.68 GB to 2.38 GB. The truthyScopes/falseyScopes caches on MutatingScope memoized whole filterBy*() result scopes per expression; instrumentation showed zero hits across self-analysis - the engine reads branch scopes from expression results now, and rule-facing filterBy*() calls do not repeat per scope. Deleted, along with their propagation in the scope-copying factories.
… the closure-type cache to a per-file WeakMap
…released after their return-statements rules
…rue message" This reverts commit 6fb2b57.
The isset-chain descriptor now recovers optional-property types in isset()/?? positions itself, so the global promotion only changed bare reads away from ErrorType.
…n explicit class name
…ession before the reflection lookup
A stored ExpressionResult only answers an on-demand ask when the asking scope agrees with its evaluation position on the variables the expression reads; a counterfactual walk (an extension re-binding a variable, e.g. array_filter evaluating its callback per constant element) re-processes the node on its own scope. A trivially-matching stored answer consumed at a foreign position is re-anchored to the asking scope so it does not thread the original walk's scopes onward.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Groundwork for the "new world" where an expression is traversed once: after
processExpr, itsExpressionResultknows the before/after scopes, the type (typeCallback) and the narrowing (specifyTypesCallback), composed from child results instead of re-walking subtrees. Handlers then stop implementingTypeResolvingExprHandler; the old entry points (MutatingScope::resolveType, theTypeSpecifierdispatcher) are guarded behindNewWorld::disableOldWorld()and get mass-deleted in PHPStan 3.0.What's on the branch, bottom up:
ExpressionResultFactory: old-world type resolution entry points throw whenNewWorld::disableOldWorld()is flipped (the migration meter); allExpressionResultconstruction goes through a generated factory.ExpressionResultcarriesbeforeScope,expr,typeCallback,specifyTypesCallbackand is stored per node inExpressionResultStorage(layered O(1)duplicate()), replacing the stored before-Scope.ExprHandler/TypeResolvingExprHandlersplit:resolveType/specifyTypesmove to the sub-interface so handlers can shed them one by one.ExpressionResultStorageStack: old-world consumers (TypeSpecifier dispatcher, extensions, rules below PHP 8.1, unconverted handlers'resolveType) keep working for converted handlers' nodes. Every scope shares the stack created by its internal scope factory;NodeScopeResolverpushes the storage of the analysis in progress throughMutatingScope::pushExpressionResultStorage()(always popped infinally, throwing on imbalance), andMutatingScopeanswers from the stored result - or processes a synthetic node on demand. Scopes never reference a storage directly, so nothing pins the result graph with the cycle collector disabled inbin/phpstan. Also addsMutatingScope::applySpecifiedTypes-filterBySpecifiedTypeswithoutScope::getType().ScalarHandlerandArrayHandlerno longer implementTypeResolvingExprHandler. The array migration is a precision win the old world cannot reach: each item type is captured at its own evaluation point, so[$b = 1, $b + 1, $c = $b, $c + 2, $c++, $c]infersarray{1, 2, 1, 3, 1, 2}.Verified: full test suite green,
make phpstanclean, and analysis memory back at baseline (no leak from the result graph despitegc_disable()).Closes phpstan/phpstan#13944
Closes phpstan/phpstan#12207
Closes phpstan/phpstan#7155
Closes phpstan/phpstan#2032
Closes phpstan/phpstan#10786
Closes phpstan/phpstan#13253
Closes phpstan/phpstan#14396
Closes phpstan/phpstan#11953
Closes phpstan/phpstan#13802
Closes phpstan/phpstan#13789
Closes phpstan/phpstan#12780
Closes phpstan/phpstan#14914
Closes phpstan/phpstan#14908
🤖 Generated with Claude Code